Skip to content

Add the lexicon provider plumbing behind the chooser - #308

Merged
imnasnainaec merged 12 commits into
mainfrom
feat/lexicon-provider-plumbing
Sep 22, 2026
Merged

imnasnainaec merged 12 commits into
mainfrom
feat/lexicon-provider-plumbing

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

The plumbing a lexicon chooser needs, with no chooser: a LexiconProvider on the port, a registry
scoped to the project in view, and FieldWorks Lite registered behind it as the first provider.

Nothing in the registry or the analysis model names FieldWorks Lite. A second provider later means
registering one, not revising this.

Partially addresses #44; the chooser and the first-open offer are still open there.

What this adds

  • LexiconProvider — { authority, isAvailable(), subscribeToLink(), connect(lexiconId?) },
    alongside the existing LexiconResolver.
    • Availability is a fact about the session; connection is a fact about a project, so several can
      be live at once.
    • connect() with no lexicon id is the load-bearing case: the resolver declares the authority and
      holds nothing, so a project with no link reads a FieldWorks Lite ref as a miss rather than
      as foreign. Same rendered output, different fact — the one FW Lite adapter: resolve lexicon refs for display #227's link indicator needs.
  • A project-scoped registry — useLexiconRegistry(projectId).
    • Asks each provider whether it can be reached, then watches each reachable one for the lexicon
      this project is linked to.
  • The link is the Lexicon extension's — subscribeToLink reads its lexicon.lexiconCode rather
    than keeping a copy.
    • Both extensions reach one lexicon per project, and clearing that setting unlinks both.
  • The FieldWorks Lite provider — availability by waiting for the lexicon.entryService network
    object, and a resolver over getSense / getEntries / addEntry.

Decisions

  • Where a link is recorded is the provider's business, the same way reaching the lexicon is.
    • lexicon.lexiconCode cannot name a non-FW-Lite lexicon, so one link slot here would need a
      second mechanism for Allow user to gloss internally and through Lexicon extension #46's tier-1 lexicon.
    • The cost: storage no longer enforces one link per project. Refs still route by the authority
      that minted them, and an affordance goes to the first available provider that can serve it.
  • One fw-lite authority, whichever store backs the lexicon.
    • FW Lite syncs a lexicon between its FwData and CRDT copies while preserving entry ids.
    • Splitting the space per backing store would strand a lexicon's refs the moment it gained a
      second copy.
  • A ref naming a lexicon other than the connected one misses, as does a ref carrying no lexicon
    id.
    • After a relink, previously linked glosses render as their stored free-form text and the refs
      survive untouched.
  • Capabilities are search/create when connected, allomorphs/msas never.
    • MiniLcm records neither: an entry carries one lexeme form and one morph type, a sense a part of
      speech without the inflection class and stem features an analysis needs.
  • A malformed lexicon record is defaulted, not dropped.
    • Its fields are declared required but arrive over PAPI, which enforces nothing.
    • Each missing field lands on behavior the render ladder already defines, so dropping would need a
      rule per field to reach the same outcomes.
  • A project is read fresh whenever it comes into view, since leaving one closes its watches and
    its link can then change unobserved.

Two things reviewers should weigh

The Lexicon extension's types are restated locally (src/types/lexicon-extension.ts, plus its
setting key in src/types/lexicon-extension-settings.d.ts), because its own declarations reach a
build only where that extension is installed — which CI and a fresh clone cannot assume. Its
declarations remain the standard. Publishing them as a package is worth filing, not worth gating
this.

resolveSense / searchByForm / createEntry are implemented here rather than left to #227.
What remains of #227 is the display wiring, which is UI and out of scope by the same rule that
leaves out the chooser. Two presentational mappings do go there: a sense's definition (rich text
in FW Lite) and its senseLabel (FW Lite does not number senses).

Deliberately not added

listLexicons(), createLexicon(draft), displayName, and any command that opens the selector.
The chooser is #44's, and the choosing affordance belongs to the provider.

Housekeeping

The "Current Lexicon gap" notes on EntryRef and SenseRef are updated: getEntry and getSense
have landed. The allomorph and MSA gaps stand.

Test plan

  • npm run lint (ESLint + stylelint + tsc --noEmit + dependency scope) — clean.
  • npm run test:coverage — 2441 tests pass, the 100% threshold holds, and every touched or added
    source file is at 100%.
  • New fw-lite-lexicon.test.ts covers availability (never registered, unfetchable, disposed and
    replaced), the unconnected resolver, foreign and unlabelled refs, writing-system narrowing,
    limits, entry creation, records missing a declared field, and the link watch.
  • Extended useLexiconRegistry covers the first-render shape, relink and clear while open, project
    switches and reopens, teardown races, and a provider that rejects.
  • Not exercised end-to-end: no run against a live FW Lite. Selecting a lexicon in the Lexicon
    extension for the project in view is the way to try it.

Depends on

sillsdev/languageforge-lexbox#2627 — this consumes IEntryService in its lexicon-addressed form and
reads that extension's project setting. Merge that first. Merged.


This change is Reviewable

Summary by CodeRabbit

  • New Features

    • Added FieldWorks Lite lexicon integration for project-linked lexicons.
    • Supports lexicon availability checks, sense lookup, entry search, and entry creation.
    • Lexicon connections update when project settings or linked lexicons change.
    • Added support for project-specific lexicon configuration and multiple lexicon providers.
  • Bug Fixes

    • Improved handling of unavailable, disconnected, missing, or unreadable lexicon services.
    • Clarified interlinearizer error logging for easier identification.

Introduce LexiconProvider on the port, scope the lexicon registry to the
project in view, record the project-to-lexicon link in two project settings,
and register FieldWorks Lite as the first provider behind it.

Availability and connection are kept apart: software that can be reached but
holds no lexicon for this project still answers for its authority, so the refs
it minted read as misses rather than as foreign.

No chooser and no first-open offer; both stay open on the issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

Warning

Review limit reached

Next included review available in 34 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6fc23fe7-8e63-4774-9b5f-cbbcdc1f8a66

📥 Commits

Reviewing files that changed from the base of the PR and between 36894b6 and bb7695a.

📒 Files selected for processing (2)
  • src/__tests__/utils/fw-lite-lexicon.test.ts
  • src/utils/fw-lite-lexicon.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 85e2e912-4724-49e1-b66a-7ebc2abd10ca

📥 Commits

Reviewing files that changed from the base of the PR and between 840aa5d and 36894b6.

📒 Files selected for processing (2)
  • src/__tests__/utils/fw-lite-lexicon.test.ts
  • src/utils/fw-lite-lexicon.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds a FieldWorks Lite lexicon provider, project-specific registry wiring, supporting type declarations, extensive tests, PAPI mocks, and mock lookup helpers. It also namespaces tokenization failure logs with the Interlinearizer: prefix.

Changes

Project lexicon integration

Layer / File(s) Summary
Lexicon contracts and FieldWorks Lite provider
src/types/*, src/utils/fw-lite-lexicon.ts, src/utils/lexicon-authorities.ts, src/types/interlinearizer.d.ts
Adds lexicon service and provider contracts. Implements FieldWorks Lite service discovery, availability caching, project-link subscription, sense lookup, search, and entry creation.
Project-linked registry wiring
src/utils/lexicon-resolvers.ts, src/hooks/useLexiconRegistry.ts, src/__tests__/hooks/useLexiconRegistry.test.ts, src/__tests__/utils/lexicon-resolvers.test.ts
Connects available providers using project links. The hook watches project changes, clears stale links, disposes subscriptions, and returns a project-specific registry.
Provider validation and test support
__mocks__/papi-frontend.ts, src/__tests__/test-helpers.ts, src/__tests__/utils/fw-lite-lexicon.test.ts
Adds PAPI network mocks and lookup helpers. Tests cover availability, service disposal, resolver behavior, searches, entry creation, malformed records, limits, and subscriptions.

Interlinearizer logging update

Layer / File(s) Summary
Tokenize failure logging
src/hooks/useInterlinearizerBookData.ts, src/__tests__/hooks/useInterlinearizerBookData.test.ts
Prefixes tokenization failure errors with Interlinearizer: and updates both related expectations.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Suggested reviewers: alex-rawlings-yyc

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding lexicon provider plumbing. The reference to the chooser provides useful context without claiming that the chooser was implemented.
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 15 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@imnasnainaec imnasnainaec self-assigned this Sep 4, 2026
@imnasnainaec

This comment was marked as outdated.

imnasnainaec and others added 4 commits September 10, 2026 10:23
The Lexicon extension owns which FW Lite lexicon a Paratext project uses,
in its own lexicon.lexiconCode project setting. Read that instead of keeping
a second copy: both extensions then reach one lexicon per project, a lexicon
chosen in either is the lexicon both use, and clearing it unlinks both.

Where a link is recorded becomes the provider's business, the same way
reaching the lexicon already was. LexiconProvider gains subscribeToLink, so
a provider whose lexicons live elsewhere records its link elsewhere, and
interlinearizer.lexiconAuthority and interlinearizer.lexiconCode go away
along with the half-a-link state they could represent.

Every provider is watched through one effect rather than one hook each, so
the hooks useLexiconRegistry runs do not vary with how many providers there
are or which of them can be reached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Lexicon extension's IEntryService now answers undefined for a lexicon
that is not there and rejects only for a fault, so a ref naming a lexicon
since deleted misses rather than throwing out of resolveSense. Restate that
here, since these declarations track that extension's and are changed with
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… for

Three fixes from review, each with a test that fails without it.

The registry paired the project in view with links read for the project
before it, for the render a switch produces. An effect cleared them, but an
effect runs after that render is on screen and after the effects of anything
rendered beneath it, so a consumer could search or create in the lexicon of
the project it had just left. Links now carry the project they were read for
and are dropped on the way out, which also retires the extra render the reset
cost.

searchByForm kept every entry holding a form in the requested writing system,
including entries the lexicon matched on another language's form or on a
gloss. The form in the requested writing system now has to be the one that
matches, folded on both sides so a match made on a pointed form survives.

Availability is gathered with allSettled: a provider that rejects rather than
answering false is misbehaving, and one of those left every other provider
unreachable.

Also give addEntry the @throws its rejection needs, per the repo's comment
rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four fixes from review, each with a test that fails without it.

Links were kept against the project they were read for, which a second visit
to that project matched. Leaving a project closes its watches, so its link can
change unobserved; a reopened project exposed what its last visit saw until
its new watch answered. Links are now dropped on the way out of a project
instead, which also retires the two guards that were compensating for state
outliving its project.

The cached entry service was held for the session. The platform revokes a
network object's proxy when it is disposed, so every lexicon read would have
thrown rather than missed once the Lexicon extension replaced its service.
Drop the reference in an onDidDispose handler, without awaiting anything,
since the proxy dies as that handler returns.

A limit below zero trimmed candidates off the end rather than capping them, so
a caller asking for at most -1 got all but one.

A watch that fails to close now says so, rather than leaving an unobserved
rejection to surface somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry service declares a sense's gloss and an entry's senses and lexeme
form required, but its records arrive from another extension over PAPI, which
enforces nothing. Three reads threw a TypeError on a record missing one, and a
fourth passed undefined off as a MultiString - a gloss rendering as nothing
with no clue why.

Every read now defends itself, and each missing field lands on behavior the
render ladder already defines: a sense with no gloss is unglossed, an entry
with no senses offers nothing to link to, an entry with no lexeme form matches
no writing system. Creating an entry that comes back senseless now reaches the
error that says so rather than throwing on the way to it - the optional chain
there guarded the entry but not its senses.

Defaulting rather than dropping records: every outcome dropping would produce
is the one defaulting already gives, without a rule per field to maintain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
imnasnainaec and others added 3 commits September 18, 2026 11:33
Drop the text that justified this change rather than describing the code:
the closed "Current Lexicon gap" notes, the reviewer-facing aside on
declaring the setting key twice, and the session-versus-project contrast
in the hook.

Fix two rule violations: `subscribeToLink` documented one of its two
parameters, as did `getMockedPapiFn`. Fold both notes into the summary
and drop the `@returns` that restated `UnsubscriberAsync`.

Correct two comments that had gone wrong: the one on `disposed` said it
guards state updates alone and then credited it with unsubscribing too,
and `createLexiconRegistry` still spoke of lexicons connected for the
session after the registry became per-project.

State the guarantee rather than the callee in the refs and in
`matchesInWritingSystem`, drop the fourfold repetition of "leaving a
project drops its links", and split the longest sentences.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec force-pushed the feat/lexicon-provider-plumbing branch from 69ce5a9 to 840aa5d Compare September 18, 2026 19:55
@imnasnainaec
imnasnainaec marked this pull request as ready for review September 18, 2026 19:56
coderabbitai[bot]

This comment was marked as resolved.

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alex-rawlings-yyc reviewed 15 files and all commit messages, and made 2 comments.
Reviewable status: all files reviewed, 2 unresolved discussions (waiting on imnasnainaec).


src/utils/fw-lite-lexicon.ts line 47 at r2 (raw file):

 */
async function getEntryService(): Promise<LexiconEntryService | undefined> {
  if (entryService) return entryService;

getEntryService caches the resolved value rather than the in-flight promise, so the cache is only set after both awaits complete. Concurrent callers therefore each open their own waitForNetworkObject and their own networkObjects.get. I probed this: two concurrent isAvailable() calls produce 2 waits and 2 fetches, and so do two concurrent resolveSense calls.

Nothing reads wrong as a result — the duplicate onDidDispose handlers this leaves on one service are idempotent. The cost is the wait. When FW Lite is absent, every concurrent batch of lexicon operations blocks on its own 10s timeout instead of sharing one, which is the shape of a first paint that resolves several refs at once.

The doc above the cache says a session "pays the wait once rather than once per connection", which is true serially and not concurrently — so this reads as the code missing its stated intent rather than as a deliberate simplification.

Caching the promise instead of the value closes it. The platform ships cached-initializer for exactly this (papi.d.ts:5023), and its doc notes it clears the cached promise on failure, which preserves the retry-after-a-failed-lookup behavior this has today and is worth keeping.

Drafted by Claude Opus 5


src/utils/fw-lite-lexicon.ts line 56 at r2 (raw file):

    // The proxy is revoked as soon as these handlers return, so this one only drops the reference:
    // anything it awaited first would be acting on a dead proxy.
    service?.onDidDispose(() => {

This handler closes over nothing that identifies which service it belongs to, so it clears whatever happens to be cached at the moment it fires rather than the service it was registered for. If the platform fires a disposed object's handlers after a replacement has been cached, the live service is dropped and the next call re-pays the wait. Probed and confirmed.

Lower priority than the caching one above, since it needs either a double-fire or a second handler created by that concurrency. Same fix shape though: capture service and clear only when entryService === service.

Drafted by Claude Opus 5

getEntryService cached the resolved service rather than the look-up, so
callers arriving while one was in flight each started their own wait and
fetch. Hold the promise instead, and clear it once it settles: a found
service still answers from the value cache, and a look-up that found
none is started afresh rather than pinned as a permanent miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handler cleared whatever was cached at the moment it fired, so a
disposed service firing again after a replacement had been cached would
drop the live one and make the next call re-pay the wait. Capture the
service and clear only on a match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imnasnainaec made 2 comments and resolved 2 discussions.
Reviewable status: 13 of 15 files reviewed, all discussions resolved (waiting on alex-rawlings-yyc).


src/utils/fw-lite-lexicon.ts line 47 at r2 (raw file):

Previously, alex-rawlings-yyc (Alex Rawlings) wrote…

getEntryService caches the resolved value rather than the in-flight promise, so the cache is only set after both awaits complete. Concurrent callers therefore each open their own waitForNetworkObject and their own networkObjects.get. I probed this: two concurrent isAvailable() calls produce 2 waits and 2 fetches, and so do two concurrent resolveSense calls.

Nothing reads wrong as a result — the duplicate onDidDispose handlers this leaves on one service are idempotent. The cost is the wait. When FW Lite is absent, every concurrent batch of lexicon operations blocks on its own 10s timeout instead of sharing one, which is the shape of a first paint that resolves several refs at once.

The doc above the cache says a session "pays the wait once rather than once per connection", which is true serially and not concurrently — so this reads as the code missing its stated intent rather than as a deliberate simplification.

Caching the promise instead of the value closes it. The platform ships cached-initializer for exactly this (papi.d.ts:5023), and its doc notes it clears the cached promise on failure, which preserves the retry-after-a-failed-lookup behavior this has today and is worth keeping.

Drafted by Claude Opus 5

createCachedInitializer isn't reachable from an extension: it's declared in shared/utils/cached-initializer, which .eslintrc bans under shared/* and webpack doesn't list in externals. Hand-rolled the same shape here. paranext/paranext-core#2845 exports it.


src/utils/fw-lite-lexicon.ts line 56 at r2 (raw file):

Previously, alex-rawlings-yyc (Alex Rawlings) wrote…

This handler closes over nothing that identifies which service it belongs to, so it clears whatever happens to be cached at the moment it fires rather than the service it was registered for. If the platform fires a disposed object's handlers after a replacement has been cached, the live service is dropped and the next call re-pays the wait. Probed and confirmed.

Lower priority than the caching one above, since it needs either a double-fire or a second handler created by that concurrency. Same fix shape though: capture service and clear only when entryService === service.

Drafted by Claude Opus 5

Done.

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alex-rawlings-yyc reviewed 2 files and all commit messages.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on alex-rawlings-yyc).

@imnasnainaec
imnasnainaec merged commit 8b1708d into main Sep 22, 2026
10 checks passed
@imnasnainaec
imnasnainaec deleted the feat/lexicon-provider-plumbing branch September 22, 2026 09:03
imnasnainaec added a commit that referenced this pull request Sep 24, 2026
The chooser #308's plumbing was built for. A Project-menu item hands off
to the lexicon software's own way of choosing or creating a lexicon, and
the project is linked to whatever the user settles on.

Nothing here names FieldWorks Lite, and nothing here shows the choosing.
A second provider adds a chooser of its own rather than a case to this
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants